iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

成品功能

  • 視窗上有「下載股票」按鈕
  • 按下後以 HttpClient 取得 JSON,解析成 StockProfile 清單
  • 將資料放入 ObservableCollection<StockProfile>,即時顯示在 DataGrid
  • 執行期間顯示 Loading,不允許重複點擊;錯誤時顯示訊息

1) 建資料服務層:IStockApiService + 實作

為了降低耦合、便於測試,先定義一個介面,由它負責抓 API 並回傳模型清單。

// Models/StockProfile.cs(若已有可沿用)
public class StockProfile
{
    public string Code { get; set; }
    public string Name { get; set; }
    public string Industry { get; set; }
    public DateTime LastUpdatedUtc { get; set; }
}
// Services/IStockApiService.cs
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

public interface IStockApiService
{
    Task<List<StockProfile>> GetStocksAsync(CancellationToken ct = default);
}
// Services/TwseStockApiService.cs
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public sealed class TwseStockApiService : IStockApiService
{
    private readonly HttpClient _http;
    private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    };

    public TwseStockApiService(HttpClient httpClient)
    {
        _http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public async Task<List<StockProfile>> GetStocksAsync(CancellationToken ct = default)
    {
        // 示意:TWSE 開放資料 (股票代號/名稱)。實務上可能需容錯欄位名。
        // 替代:可先用你 Day 12 的 Crawler 包裝好在這裡呼叫。
        var url = "https://openapi.twse.com.tw/v1/opendata/t187ap03_L";
        string json = await _http.GetStringAsync(url, ct);

        // 多數開放資料為陣列物件;這裡示意直接反序列化為動態再投影成模型。
        using var doc = JsonDocument.Parse(json);
        var result = new List<StockProfile>();
        foreach (var e in doc.RootElement.EnumerateArray())
        {
            string code = e.TryGetProperty("公司代號", out var c1) ? c1.GetString()
                       : e.TryGetProperty("Code", out var c2)     ? c2.GetString()
                       : null;
            string name = e.TryGetProperty("公司簡稱", out var n1) ? n1.GetString()
                       : e.TryGetProperty("Name", out var n2)     ? n2.GetString()
                       : null;
            string industry = e.TryGetProperty("產業別", out var i1) ? i1.GetString()
                           : e.TryGetProperty("Industry", out var i2) ? i2.GetString()
                           : null;

            if (!string.IsNullOrWhiteSpace(code) && !string.IsNullOrWhiteSpace(name))
            {
                result.Add(new StockProfile
                {
                    Code = code,
                    Name = name,
                    Industry = industry ?? string.Empty,
                    LastUpdatedUtc = DateTime.UtcNow
                });
            }
        }
        return result;
    }
}

2) 建立 AsyncCommand(可沿用 Day 19)

// Infra/AsyncCommand.cs
using System;
using System.Threading.Tasks;
using System.Windows.Input;

public class AsyncCommand : ICommand
{
    private readonly Func<Task> _execute;
    private readonly Func<bool> _canExecute;
    private bool _isExecuting;

    public AsyncCommand(Func<Task> execute, Func<bool> canExecute = null)
    {
        _execute = execute ?? throw new ArgumentNullException(nameof(execute));
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true);

    public async void Execute(object parameter)
    {
        _isExecuting = true; RaiseCanExecuteChanged();
        try { await _execute(); }
        finally { _isExecuting = false; RaiseCanExecuteChanged(); }
    }

    public event EventHandler CanExecuteChanged;
    public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

3) ViewModel:綁定資料與命令

  • 透過 ObservableCollection<StockProfile> 供 DataGrid 顯示
  • LoadStocksCommand 非同步抓資料
  • IsBusy 控制按鈕狀態與 Loading
  • ErrorMessage 顯示錯誤
// ViewModels/StockListViewModel.cs
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;

public class StockListViewModel : INotifyPropertyChanged
{
    private readonly IStockApiService _api;
    private CancellationTokenSource _cts;
    private bool _isBusy;
    private string _errorMessage;

    public ObservableCollection<StockProfile> Stocks { get; } = new ObservableCollection<StockProfile>();

    public bool IsBusy
    {
        get => _isBusy;
        private set { _isBusy = value; OnPropertyChanged(nameof(IsBusy)); }
    }

    public string ErrorMessage
    {
        get => _errorMessage;
        private set { _errorMessage = value; OnPropertyChanged(nameof(ErrorMessage)); }
    }

    public ICommand LoadStocksCommand { get; }
    public ICommand CancelCommand { get; }

    public StockListViewModel(IStockApiService api)
    {
        _api = api ?? throw new ArgumentNullException(nameof(api));
        LoadStocksCommand = new AsyncCommand(LoadStocksAsync, () => !IsBusy);
        CancelCommand = new AsyncCommand(CancelAsync, () => IsBusy);
    }

    private async Task LoadStocksAsync()
    {
        ErrorMessage = null;
        IsBusy = true;
        _cts = new CancellationTokenSource();
        try
        {
            var list = await _api.GetStocksAsync(_cts.Token);

            Stocks.Clear();
            foreach (var s in list)
                Stocks.Add(s);
        }
        catch (OperationCanceledException)
        {
            ErrorMessage = "已取消下載。";
        }
        catch (Exception ex)
        {
            ErrorMessage = $"下載失敗:{ex.Message}";
        }
        finally
        {
            IsBusy = false;
            _cts = null;
            (LoadStocksCommand as AsyncCommand)?.RaiseCanExecuteChanged();
            (CancelCommand as AsyncCommand)?.RaiseCanExecuteChanged();
        }
    }

    private Task CancelAsync()
    {
        _cts?.Cancel();
        return Task.CompletedTask;
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

4) View:XAML 綁定 UI 與狀態

  • 上方按鈕列:下載、取消下載
  • 下載中顯示 ProgressBar
  • 下方 DataGrid 顯示 Stocks
  • 用內建 BooleanToVisibilityConverter 控制 Loading 顯示
<!-- Views/MainWindow.xaml -->
<Window x:Class="MyStockApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="選股工具 - 資料下載" Height="500" Width="800">
    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="Bool2Vis"/>
    </Window.Resources>

    <DockPanel>
        <!-- 工具列 -->
        <StackPanel Orientation="Horizontal" DockPanel.Dock="Top" Margin="8" HorizontalAlignment="Left" Spacing="8">
            <Button Content="下載股票"
                    Command="{Binding LoadStocksCommand}"
                    IsEnabled="{Binding IsBusy, Converter={StaticResource Bool2Vis}, ConverterParameter=Invert}">
                <!-- 上面這行若要更直覺可改用 Triggers 或多一個反向轉換器;此處簡化可先不綁定 IsEnabled -->
            </Button>

            <Button Content="取消"
                    Command="{Binding CancelCommand}"
                    IsEnabled="{Binding IsBusy}"/>
            
            <ProgressBar Width="150" Height="16"
                         IsIndeterminate="True"
                         Visibility="{Binding IsBusy, Converter={StaticResource Bool2Vis}}"/>
        </StackPanel>

        <!-- 錯誤訊息 -->
        <TextBlock Margin="8" Foreground="Tomato" Text="{Binding ErrorMessage}"/>

        <!-- 資料表格 -->
        <DataGrid ItemsSource="{Binding Stocks}" AutoGenerateColumns="False" Margin="8"
                  EnableRowVirtualization="True" IsReadOnly="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="代號" Binding="{Binding Code}" Width="100"/>
                <DataGridTextColumn Header="名稱" Binding="{Binding Name}" Width="200"/>
                <DataGridTextColumn Header="產業" Binding="{Binding Industry}" Width="*"/>
                <DataGridTextColumn Header="更新時間 (UTC)"
                                    Binding="{Binding LastUpdatedUtc, StringFormat=\{0:yyyy-MM-dd HH:mm:ss\}}" Width="200"/>
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</Window>

說明

  • BooleanToVisibilityConverterIsBusyVisibility,顯示/隱藏進度條
  • ObservableCollection 在新增/清除時,DataGrid 會自動刷新
  • IsReadOnly="True" 確保資料表不被直接編輯(此處僅顯示)

5) 設定 DataContext(View First 範例)

在程式進入點或 MainWindow.xaml.cs 注入服務與 ViewModel:

// Views/MainWindow.xaml.cs
using System.Net.Http;
using System.Windows;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        var http = new HttpClient(); // 正式專案建議使用 IHttpClientFactory
        var api  = new TwseStockApiService(http);
        this.DataContext = new StockListViewModel(api);
    }
}

6) 非同步更新 UI 的關鍵

  • 不要在 UI 執行緒上做長時間工作,統一放在 await 的方法內
  • 集合用 ObservableCollection:新增/刪除會通知 UI
  • 屬性變更用 INotifyPropertyChangedIsBusyErrorMessage 等狀態才會即時反映

7) 加分:最佳化與防呆

  • 取消請求:用 CancellationTokenSource,提供「取消」功能
  • 錯誤訊息:例外統一捕捉到 ErrorMessage 顯示
  • 避免重複點擊:執行期間 LoadStocksCommand.CanExecute 回傳 false
  • 虛擬化EnableRowVirtualization="True",大量列時較順暢

小結

今天把前面的知識整合成可用的 WPF 畫面:

  • 按下按鈕 → 非同步呼叫 API → 解析 JSON → 填入 ObservableCollection → DataGrid 即時顯示
  • 下載中顯示 Loading、允許取消、錯誤時顯示訊息
  • 架構上以 介面 + 服務層 + ViewModel + View 分離責任,便於後續擴充與測試


上一篇
Day 19 - WPF Command 模式
下一篇
Day 21 - 儲存資料到 LiteDB
系列文
30天快速上手製作WPF選股工具 — 從C#基礎到LiteDB與Web API整合21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言